上一篇文章我們實作了一個基本的購物車,但現行市面上的電商平台購物車絕對都更加複雜。以蝦皮為例,可以發現要進入購物車必須要有會員資格。即使關閉App或是更換裝置,購物車的狀態都會保留。最後,購物車的狀態會與結帳資料進行耦合。例如,在結帳時若有商品失效,返回購物車後,除了保留前次勾選的項目外,商品狀態也會隨之更新。這麼做的原因主要是因為這些功能能夠提升用戶體驗、增強安全性及提高系統的效率。
- 限制會員可以確保交易的安全性,後台也較容易追蹤會員的購物行為,蒐集數據,且會員也能夠享受更加個性化的服務和優惠。
- 將購物車的狀態紀錄在後端可以確保資料的一致性,可以防止併發時可能的超賣或是庫存不即時等狀況。
- 結帳資訊與購物車狀態的耦合可以提升使用者購物的流暢度,減少使用者困惑的情形。
那麼前端該怎麼對應呢?
本篇文章將會針對購物車儲存在後端的功能進行說明。
在繼續往下前,需要說明一下 NgRx 的概念。
Store: 前端的數據庫(DB),它用來儲存應用程式的狀態和數據。在應用程式運行過程中,所有需要的數據都會儲存在 Store 中。這樣,我們可以隨時訪問和管理這些數據。當我們的應用程式需要顯示或更新某些信息時,它會從 Store 中讀取或寫入數據。
Reducer:可以視為對數據庫進行修改(Create、Update、Delete,CUD)的角色。當我們需要改變 Store 中的數據時,會發送一個 Action,這時 Reducer 就會根據這個 Action 來執行相應的操作。Reducer 是一個純函數,它會接收當前的狀態和 Action,並返回新的狀態,這就像在數據庫中進行一筆修改或更新。
Selector:專門用來查詢數據庫的工具,能夠幫助我們方便地獲取所需的數據。當我們需要從 Store 中讀取數據時,就會使用 Selector。它們可以從 Store 中提取出特定的數據片段,幫助我們更高效地獲取和顯示信息,而不必每次都直接操作 Store。
Effects:負責處理副作用的角色,專門用來處理需要進行外部操作的情況,比如呼叫 API。
詳細說明:當應用需要與外部系統進行交互(例如發送 HTTP 請求)時,Effects 就會發揮作用。它會監聽特定的 Action,然後執行相應的邏輯(如呼叫 API),在獲得響應後,再根據需要發送新的 Action 來更新 Store。Action:發起請求的角色,就像是通知數據庫要進行某項操作的命令。每當應用中的某個事件發生(例如用戶點擊按鈕、接收到數據等),就會觸發一個 Action。這些 Action 是指令,告訴 Reducer 或 Effects 要執行什麼操作。Action 可以由多個地方發起,包括組件(Component)、服務(Service)或 Effects。
首先我們需要調整一下程式的流程。
原本 cart.component.ts 直接呼叫 cart.service.ts 透過封裝好的邏輯呼叫 action,reducer 監聽到 action,更新購物車 state。
加入後端的情況會變得比較複雜。cart.component.ts 呼叫 cart.service.ts 觸發 action,effect 監聽到 action 呼叫 api.service 告訴後端要更新的內容,獲得成功的回傳後,effect 觸發另一個 action,reducer 監聽到 action 進行購物車狀態的更新。
首先,我們要加入一個 api.service.ts 來封裝與後端互動的 API 呼叫。
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { CartItem } from '../models/cart-item.model';
@Injectable({
  providedIn: 'root'
})
export class ApiService {
  private baseUrl = 'https://your-backend-api.com/api'; // 後端 API 基礎 URL
  constructor(private http: HttpClient) {}
  // 獲取購物車資料
  getCart(): Observable<CartItem[]> {
    return this.http.get<CartItem[]>(`${this.baseUrl}/cart`);
  }
  // 更新購物車
  updateCart(items: CartItem[]): Observable<any> {
    return this.http.put(`${this.baseUrl}/cart`, { items });
  }
  // 刪除購物車中的商品
  removeFromCart(itemId: number): Observable<any> {
    return this.http.delete(`${this.baseUrl}/cart/${itemId}`);
  }
  // 清空購物車
  clearCart(): Observable<any> {
    return this.http.delete(`${this.baseUrl}/cart`);
  }
}
所以下一步,我們要調整原本的 cart.actions.ts 檔案,將原本單純的行為拆分成觸發和成功兩種。
import { createAction, props } from '@ngrx/store';
import { CartItem } from '../models/cart-item.model';
// 觸發
export const addToCart = createAction(
  '[Cart] Add to Cart',
  props<{ item: CartItem }> ()
);
// 成功
export const addToCartSuccess = createAction(
  '[Cart] Add to Cart Success',
  props<{ item: CartItem }> ()
);
接著要新增負責處理 API 的 cart.effects.ts。
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { ApiService } from '../services/api.service';
import { addToCart, addToCartSuccess } from './cart.actions';
import { map, mergeMap, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
@Injectable()
export class CartEffects {
  constructor(
    private actions$: Actions,
    private apiService: ApiService
  ) {}
  // 當添加商品時,先呼叫後端 API
  addToCart$ = createEffect(() =>
    this.actions$.pipe(
      ofType(addToCart),
      mergeMap(action =>
        this.apiService.updateCart([action.item]).pipe(
          map(() => {
            // 更新 Store 的 Action
            return addToCartSuccess({ item: action.item });
          }),
          catchError(error => {
            console.error(error);
            // 錯誤處理
            return EMPTY;
          })
        )
      )
    )
  );
}
當 addToCart 被觸發時,使用 mergeMap 運算子來呼叫 apiService.updateCart,把添加的商品傳送到後端 API。若 API 成功,就會使用 map 運算子來創建一個新的 Action addToCartSuccess,將添加的商品更新到 Store 中。
接著要調整 reducer,從原本監聽 addToCart 這個 Action 變成監聽 API 成功後觸發的 addToCartSuccess Action。
import { createReducer, on } from '@ngrx/store';
import { CartState } from './cart.state';
import { addToCart, addToCartSuccess, removeFromCart, updateQuantity, clearCart } from './cart.actions';
export const initialState: CartState = {
  items: []
};
export const cartReducer = createReducer(
  initialState,
  // 原本是 addToCart,但現在 addToCart 變成觸發 API 用的 action 了
  on(addToCartSuccess, (state, { item }) => {
    const existingItem = state.items.find(i => i.id === item.id);
    if (existingItem) {
      existingItem.quantity += item.quantity;
      return { ...state };
    }
    return { ...state, items: [...state.items, item] };
  }),
);
到這裡我們就成功地將後端的流程加入原本純前端的購物車流程中。同時也熟悉了 NgRx、Action、Effect、Reducer、Selector 四件套的應用方式。
目前的專案架構如下:
src/
├── app/
│   ├── models/
│   │   └── cart-item.model.ts
│   ├── services/
│   │   ├── cart.service.ts            
│   │   └── api.service.ts             # 與後端 API 通訊的服務
│   ├── state/
│   │   ├── cart.actions.ts
│   │   ├── cart.effect.ts             # 處理 side effect
│   │   ├── cart.reducer.ts
│   │   ├── cart.selectors.ts
│   │   └── cart.state.ts
│   ├── components/
│   │   ├── product-list/
│   │   │   ├── product-list.component.ts
│   │   │   └── product-list.component.html
│   │   ├── cart/
│   │   │   ├── cart.component.ts
│   │   │   └── cart.component.html
│   │   └── checkout/
│   │       ├── checkout.component.ts
│   │       └── checkout.component.html
│   ├── app.component.ts
│   └── app.config.ts
下一篇文章我們會討論需要將購物車狀態和結帳資料進行耦合的功能部分。